2026_giorgio_moroder_chase.py

#

SPDX-FileCopyrightText: 2026 Sarah Fanning & Joshua Neville SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

import random

import bmesh
import bpy
#

CLEAR SCENE #

bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
#

SETTINGS #

x_lines = 11
y_lines = 11

line_width = 0.200
line_height = 0.200
#

RANDOM SETTINGS # RANDOM_SEED locks the random sequence for reproducibility Set to None for a new result on every run RANDOM_HEIGHT_MIN / MAX controls the height range for outer pillar randomisation #

RANDOM_SEED = 21
RANDOM_HEIGHT_MIN = 2.5
RANDOM_HEIGHT_MAX = 4.5

if RANDOM_SEED is not None:
    random.seed(RANDOM_SEED)
#

SPACING MAPS # X-axis: raw random values normalised so total X span equals total Y span Preserves the random character of the snare while keeping the grid square randomness contained by structure — the snare lives inside the time signature #

raw = [random.uniform(0.5, 1.5) for _ in range(x_lines - 1)]
total = sum(raw)
target = (y_lines - 1) * 1.0  # = 10.0, same as y total

x_spacing = [round(v * target / total, 4) for v in raw]

y_spacing = [1.0] * (y_lines - 1)  # stays uniform

T = line_width / 2
#

THICKNESS MAPS # 0.500 marks the 5x5 core zone - thicker pillars make the core visually distinct T is the default thin pillar used everywhere outside the core Lower and upper maps are identical in zone layout but serve independent halves #

lower_thickness = [
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
]

upper_thickness = [
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, 0.500, 0.500, 0.500, 0.500, 0.500, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
    [T, T, T, T, T, T, T, T, T, T, T],
]
#

FIXED CORE PILLAR VALUES # The 5x5 core zone is fully hand-composed — no randomisation Two-tier structure within the core: inner 3x3: fixed at 5 — the stable, unmovable centre outer ring: diagonal gradients climbing toward the corners Lower half: gradient climbs from top-left to bottom-right Upper half: gradient climbs from bottom-left to top-right — the same material, inverted The two cores face each other without touching, same logic irreconcilable in space

_core_lower = {
    (3, 3): 5,
    (3, 4): 4.75,
    (3, 5): 4.50,
    (3, 6): 4.25,
    (3, 7): 4,
    (4, 3): 4.75,
    (4, 4): 5,
    (4, 5): 5,
    (4, 6): 5,
    (4, 7): 4.25,
    (5, 3): 4.50,
    (5, 4): 5,
    (5, 5): 5,
    (5, 6): 5,
    (5, 7): 4.50,
    (6, 3): 4.25,
    (6, 4): 5,
    (6, 5): 5,
    (6, 6): 5,
    (6, 7): 4.75,
    (7, 3): 4,
    (7, 4): 4.25,
    (7, 5): 4.50,
    (7, 6): 4.75,
    (7, 7): 5,
}

_core_upper = {
    (3, 3): 4,
    (3, 4): 4.25,
    (3, 5): 4.50,
    (3, 6): 4.75,
    (3, 7): 5,
    (4, 3): 4.25,
    (4, 4): 5,
    (4, 5): 5,
    (4, 6): 5,
    (4, 7): 4.75,
    (5, 3): 4.50,
    (5, 4): 5,
    (5, 5): 5,
    (5, 6): 5,
    (5, 7): 4.50,
    (6, 3): 4.75,
    (6, 4): 5,
    (6, 5): 5,
    (6, 6): 5,
    (6, 7): 4.25,
    (7, 3): 5,
    (7, 4): 4.75,
    (7, 5): 4.50,
    (7, 6): 4.25,
    (7, 7): 4,
}
#

RANDOMISED HEIGHT MAP BUILDER # rnd() draws a random outer pillar height within RANDOM_HEIGHT_MIN / MAX #

def rnd():
    return round(random.uniform(RANDOM_HEIGHT_MIN, RANDOM_HEIGHT_MAX), 4)
#
def build_height_map(thickness_map, core_values):
    row_h_left = [rnd() for _ in range(y_lines)]
    row_h_right = [rnd() for _ in range(y_lines)]

    height_map = []
    for y_idx in range(y_lines):
        h_left = row_h_left[y_idx]
        h_right = row_h_right[y_idx]
        row = []
        for x_idx in range(x_lines):
            if thickness_map[y_idx][x_idx] == 0.500:
                row.append(core_values[(y_idx, x_idx)])
            else:
                frac = x_idx / (x_lines - 1)
                h = h_left + (h_right - h_left) * frac
                row.append(round(h, 4))
        height_map.append(row)
    return height_map


lower_heights = build_height_map(lower_thickness, _core_lower)
upper_heights = build_height_map(upper_thickness, _core_upper)
#

CURVE CONTROLS # 24 interpolated steps per segment — working resolution for the Catmull-Rom surfaces #

CURVE_STEPS = 24
#

VALIDATION # n lines need n-1 gaps — catches spacing list mismatches before any geometry is built

assert len(x_spacing) == x_lines - 1
assert len(y_spacing) == y_lines - 1
#

BUILD POSITIONS # Converts spacing gaps into absolute world coordinates Accumulates each gap from 0.0 then recentres so the structure sits at the world origin #

def build_positions(spacing_list):
    positions = [0.0]
    for gap in spacing_list:
        positions.append(positions[-1] + gap)
    offset = positions[-1] / 2.0
    return [p - offset for p in positions]


x_positions = build_positions(x_spacing)
y_positions = build_positions(y_spacing)

x_total = x_positions[-1] - x_positions[0]
y_total = y_positions[-1] - y_positions[0]

flip_base = y_total
ceiling = flip_base * 1
#

HELPERS #

def make_bar(location, scale):
    bpy.ops.mesh.primitive_cube_add(location=location)
    obj = bpy.context.object
    obj.scale = scale
    return obj
#
def get_lower(y_idx, x_idx):
    try:
        return lower_heights[y_idx][x_idx]
    except IndexError:
        return 1.0
#
def get_upper(y_idx, x_idx):
    try:
        return upper_heights[y_idx][x_idx]
    except IndexError:
        return 1.0
#
def get_lower_thickness(y_idx, x_idx):
    try:
        return lower_thickness[y_idx][x_idx]
    except IndexError:
        return T
#
def get_upper_thickness(y_idx, x_idx):
    try:
        return upper_thickness[y_idx][x_idx]
    except IndexError:
        return T
#
def catmull_rom(p0, p1, p2, p3, t):
    return 0.5 * (
        2 * p1
        + (-p0 + p2) * t
        + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t
        + (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t
    )
#

Y-AXIS # Reusable function that builds a single smooth Y-axis surface panel z_top_fn and z_bot_fn are passed in so the same function serves both halves: lower half: z_top = h, z_bot = 0.0 upper half: z_top = ceiling, z_bot = ceiling - h hy_ext / yp_ext extend with phantom points so the spline doesn’t kink at the boundaries top and bottom vertices paired into quad faces to build each surface strip #

def build_curved_surface(name, x_pos, height_fn, z_top_fn, z_bot_fn):
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    bm = bmesh.new()
    top_verts, bot_verts = [], []

    hy = [height_fn(y_idx) for y_idx in range(y_lines)]
    yp = y_positions
    hy_ext = [hy[0]] + hy + [hy[-1]]
    yp_ext = [yp[0] - (yp[1] - yp[0])] + list(yp) + [yp[-1] + (yp[-1] - yp[-2])]

    for seg in range(y_lines - 1):
        i = seg + 1
        p0h, p1h, p2h, p3h = hy_ext[i - 1], hy_ext[i], hy_ext[i + 1], hy_ext[i + 2]
        p0y, p1y, p2y, p3y = yp_ext[i - 1], yp_ext[i], yp_ext[i + 1], yp_ext[i + 2]
        steps = CURVE_STEPS + (1 if seg == y_lines - 2 else 0)

        for step in range(steps):
            t = step / CURVE_STEPS
            h = catmull_rom(p0h, p1h, p2h, p3h, t)
            y_pt = catmull_rom(p0y, p1y, p2y, p3y, t)
            top_verts.append(bm.verts.new((x_pos, y_pt, z_top_fn(h))))
            bot_verts.append(bm.verts.new((x_pos, y_pt, z_bot_fn(h))))

    bm.verts.ensure_lookup_table()
    for i in range(len(top_verts) - 1):
        try:
            bm.faces.new(
                [top_verts[i], top_verts[i + 1], bot_verts[i + 1], bot_verts[i]]
            )
        except:
            pass
    bm.to_mesh(mesh)
    bm.free()
#

X-AXIS # Builds a flat vertical mesh panel connecting pillar tops across one row Interpolates height linearly between the first and last pillar using world-space X positions matches the same ramp logic used in build_height_map — pillar and surface stay aligned #

def build_straight_x_surface(name, y_pos, y_idx, height_fn, z_top_fn, z_bot_fn):
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    bm = bmesh.new()
    top_verts, bot_verts = [], []

    h_first = height_fn(y_idx, 0)
    h_last = height_fn(y_idx, x_lines - 1)
    x_first = x_positions[0]
    x_last = x_positions[-1]
    x_span = x_last - x_first

    for x_idx, x_pos in enumerate(x_positions):
        frac = (x_pos - x_first) / x_span
        h = h_first + (h_last - h_first) * frac
        top_verts.append(bm.verts.new((x_pos, y_pos, z_top_fn(h))))
        bot_verts.append(bm.verts.new((x_pos, y_pos, z_bot_fn(h))))

    bm.verts.ensure_lookup_table()
    for i in range(x_lines - 1):
        try:
            bm.faces.new(
                [top_verts[i], top_verts[i + 1], bot_verts[i + 1], bot_verts[i]]
            )
        except:
            pass
    bm.to_mesh(mesh)
    bm.free()
#

LOWER HALF #

#

Horizontal rows - flat bars along X at each Y position, spanning full grid width Kick drum pulse: uniform, metronomic, the anchor of the structure

#

Vertical columns - flat bars along Y at each X position, spanning full grid depth Snare pulse: irregular spacing, forward momentum against the kick’s anchor

#

Intersection pillars - vertical bar at every grid crossing Height from get_lower, thickness from get_lower_thickness location z = h/2 so the base sits flush on the ground plane

#

Straight x-row surfaces # z_top = h, z_bot = 0.0 - surface rises from the floor tracing the ramp profile

#

Curved y-column surfaces # z_top = h, z_bot = 0.0 The wave rises from the floor - Moroder’s layering logic made geometric #

for y_idx, y_pos in enumerate(y_positions):
    make_bar(
        location=(0, y_pos, line_height / 2),
        scale=((x_total / 2) + line_width / 2, line_width / 2, line_height / 2),
    )

for x_idx, x_pos in enumerate(x_positions):
    make_bar(
        location=(x_pos, 0, line_height / 2),
        scale=(line_width / 3, (y_total / 2) + line_width / 2, line_height / 2),
    )

for x_idx, x_pos in enumerate(x_positions):
    for y_idx, y_pos in enumerate(y_positions):
        h = get_lower(y_idx, x_idx)
        t = get_lower_thickness(y_idx, x_idx)
        make_bar(location=(x_pos, y_pos, h / 2), scale=(t, t, h / 2))
#

Straight x-row surfaces #

for y_idx, y_pos in enumerate(y_positions):
    build_straight_x_surface(
        name=f"surface_x_row_{y_idx}",
        y_pos=y_pos,
        y_idx=y_idx,
        height_fn=get_lower,
        z_top_fn=lambda h: h,
        z_bot_fn=lambda h: 0.0,
    )
#

Curved y-column surfaces #

for x_idx, x_pos in enumerate(x_positions):
    build_curved_surface(
        name=f"surface_y_col_{x_idx}",
        x_pos=x_pos,
        height_fn=lambda yi, _xi=x_idx: get_lower(yi, _xi),
        z_top_fn=lambda h: h,
        z_bot_fn=lambda h: 0.0,
    )
#

UPPER HALF # The lower half mirrored and inverted to hang from the ceiling Independent height maps, same structural logic - same material, different voice The two halves face each other across the interior without touching: identical in structure, irreconcilable in space

#

Horizontal rows at ceiling - positioned at ceiling - line_height/2 so bars sit flush against the ceiling rather than the floor

#

Vertical columns at ceiling - same as lower columns, mirrored upward

#

Flipped pillars - location z = ceiling - h/2 so pillars hang downward from the ceiling

#

Straight x-row surfaces — flipped # z_top = ceiling, z_bot = ceiling - h Mirror of the lower x-row surfaces, descending rather than ascending

#

Curved y-column surfaces — flipped # z_top = ceiling, z_bot = ceiling - h The wave descends from above — the same surface logic, inverted

for y_idx, y_pos in enumerate(y_positions):
    make_bar(
        location=(0, y_pos, ceiling - line_height / 2),
        scale=((x_total / 2) + line_width / 2, line_width / 2, line_height / 2),
    )

for x_idx, x_pos in enumerate(x_positions):
    make_bar(
        location=(x_pos, 0, ceiling - line_height / 2),
        scale=(line_width / 2, (y_total / 2) + line_width / 2, line_height / 2),
    )

for x_idx, x_pos in enumerate(x_positions):
    for y_idx, y_pos in enumerate(y_positions):
        h = get_upper(y_idx, x_idx)
        t = get_upper_thickness(y_idx, x_idx)
        make_bar(location=(x_pos, y_pos, ceiling - h / 2), scale=(t, t, h / 2))
#

Straight x-row surfaces #

for y_idx, y_pos in enumerate(y_positions):
    build_straight_x_surface(
        name=f"flip_surface_x_row_{y_idx}",
        y_pos=y_pos,
        y_idx=y_idx,
        height_fn=get_upper,
        z_top_fn=lambda h: ceiling,
        z_bot_fn=lambda h: ceiling - h,
    )
#

Curved y-column surfaces #

for x_idx, x_pos in enumerate(x_positions):
    build_curved_surface(
        name=f"flip_surface_y_col_{x_idx}",
        x_pos=x_pos,
        height_fn=lambda yi, _xi=x_idx: get_upper(yi, _xi),
        z_top_fn=lambda h: ceiling,
        z_bot_fn=lambda h: ceiling - h,
    )
#

The final Cube! #

#

Y-axis: uniform spacing (1.0 throughout): the kick drum, metronomic and absolute, the pulse doesn’t deviate # X-axis: randomised: the snare, unpredictable internally but always resolving to the same width # The wave is Moroder’s core provocation made physical: tension without harmony # No chord changes, no resolution, only accumulation. Each Y-column layer stacks onto the same pulse, smooth and inevitable # The pillars are call and response. The synthesizer voices answering the sequencer’s ground # Outer pillars: algorithmically derived, interpolated from edge values, servants of the ramp, the sequencer setting the terms # Core pillars: independently generated from a separate height range, taller, occupying the centre, the synthesizer claiming its own voice # The tension between the calculated outer field and the autonomous core is the same tension Moroder engineered between the mechanical and the expressive # The snare creates forward momentum against the kick’s anchor, irregular, never quite where you expect it. Fixed spacing would be a grid, not a score # The normalisation back to a fixed total span is the critical move: randomness contained by structure - the snare lives inside the time signature, it doesn’t break it # The core sits inside the ramp defined by the outer pillars - calculated independently it would pierce or sink below the mesh, breaking the logic # The core has directionality - not a flat plateau but a landscape with internal momentum, the same way Chase is not static repetition but repetition that is always going somewhere #